Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

6차 세미나 실습 과제 (#13) #15

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open

6차 세미나 실습 과제 (#13) #15

wants to merge 17 commits into from

Conversation

sjk4618
Copy link
Contributor

@sjk4618 sjk4618 commented May 30, 2024

🔥Pull requests

⛳️ 작업한 브랜치

👷 작업한 내용

  • 로그인 할 때, AccessToken과 RefreshToken을 함께 반환하는 로직
  • Redis를 활용해 RefreshToken으로 AccessToken을 재발급 받는 로직

🚨 참고 사항

1. Jwt

역할에 따라 JwtGenerator, JwtProvider, JwtValidator로 나누었습니다.

  • Jwt를 발급할 때, claim을 직접 선언하는 것이 아니라, 제공된 메서드니 subSubject를 이용했습니다.
  • 또한, 추후에 토큰 재발급할 때, refreshToken도 재발급해주기 위해서 매개변수로 isAccessToken도 받습니다.
public String generateToken(final Long userId, boolean isAccessToken) {
        final Date presentDate = new Date();
        final Date expireDate = generateExpireDataByToken(isAccessToken, presentDate);

        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setSubject(String.valueOf(userId))
                .setIssuedAt(presentDate)
                .setExpiration(expireDate)
                .signWith(getSigningKey(), SignatureAlgorithm.HS256) //여기서 어떤 알고리즘을 사용할 지를 명시적으로 적어주는게 좋음, 안 적어주면 라이브러리 기본 설정에 의존하게됨
                .compact();
    }

2. RTR(Refresh Token Rotation)

만약 refresh token을 탈취당한다면 refresh token이 만료되기 전까지 이 refresh token으로 계속 access token을 발급해낼 수 있기 때문에 RTR 방식으로 코드를 구현했습니다.
그래서 멤버 가입을 할 때 뿐만 아니라, accessToken 재발급을 할 때도 accessToken과 refreshToken을 재발급해줍니다.

  • 멤버 id와 refreshToken을 가지고 리프레시토큰을 검증하는 과정을 우선 거칩니다.
  • memberId로 refreshToken을 가져와서 같은 리프레시 토큰인지 검증합니다.
  • 만약 여기서 UnauthorizedException이 발생하면 로그아웃을 시켜서 새로 로그인하게 만듭니다.
  • 모든 검증이 끝나면 accessToken과 refreshToken을 재발급해줍니다.
    @Transactional
    public TokenAndUserIdResponse reissue(final String refreshToken, final ReissueRequest reissueRequest) {

        Long memberId = reissueRequest.memberId();
        validateRefreshToken(refreshToken,memberId);
        Member member = findMemberById(memberId);
        Token issueedToken = jwtTokenProvider.issueTokens(memberId);
        updateRefreshToken(issueedToken.refreshToken(), memberId);
        return TokenAndUserIdResponse.of(issueedToken, memberId);
    }

    private void validateRefreshToken(final String refreshToken, final Long memberId) {
        try {
            jwtTokenValidator.validateRefreshToken(refreshToken);
            String storedRefreshToken = getRefreshToken(memberId);
            jwtTokenValidator.equalsRefreshToken(refreshToken, storedRefreshToken);
        } catch (UnauthorizedException e) {
            signOut(memberId);
            throw e;
        }
    }
    public void validateRefreshToken(String refreshToken) {
        try {
            parseToken(refreshToken);
        } catch (ExpiredJwtException e) {
            throw new UnauthorizedException(ErrorMessage.EXPIRED_REFRESH_TOKEN);
        } catch (Exception e) {
            throw new UnauthorizedException(ErrorMessage.INVALID_REFRESH_TOKEN_VALUE);
        }
    }

    private void parseToken(String token) {
        JwtParser jwtParser = jwtTokenGenerator.getJwtParser();
        jwtParser.parseClaimsJws(token);
    }

3. UserIdArgumentResolver

accessToken을 가지고 매 컨트롤러에서 해줘야 하는 token파싱대신 Argument Resolver를 이용하면 중복 로직을 제거할 수 있습니다.

  • 커스텀 어노테이션(memberId)
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface MemberId {
}
  • UserIdArgumentResolver
@Component
public class UserIdArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {

        //요청받은 메소드의 파라미터에  @UserId 어노테이션이 붙어있는지 확인
        boolean hasUserIdAnnotation = parameter.hasParameterAnnotation(MemberId.class);

        //타입이 같은지 확인
        boolean isLongType = Long.class.isAssignableFrom(parameter.getParameterType());

        //둘 다 true면 아래 resolveArgument 메서드 실행
        return hasUserIdAnnotation && isLongType;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return SecurityContextHolder.getContext()
                .getAuthentication()
                .getPrincipal();
    }
}
  • 사용
   @GetMapping
    public ResponseEntity<BaseResponse<?>> findMemberById(@MemberId final Long memberId) {

        final MemberFindDTO memberFindDTO = MemberFindDTO.of(memberService.findMemberById(memberId));

        return ApiResponseUtil.success(SuccessMessage.MEMBER_FIND_SUCCESS, memberFindDTO);
    }

Copy link

@jinkonu jinkonu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

많은 분들의 제출을 보진 못했지만
코드에서 굉장히 많은 고민과 노력을 하셨다는 걸 느낄 수 있었습니다!!
특히 어노테이션 활용하는 건 제가 배워가도록 하겠습니다 ㅎㅎ

@@ -15,12 +18,15 @@
@RequiredArgsConstructor
@EnableWebSecurity //web Security를 사용할 수 있게
public class SecurityConfig {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개인적으로 config 파일은 따로 디렉토리를 생성해서 분류해주면 좋을 것 같네요!!

Comment on lines +27 to +31
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
return SecurityContextHolder.getContext()
.getAuthentication()
.getPrincipal();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아마 여기서 매개변수를 파싱하는 것 같은데,
제가 이해했을 때에는 토큰 파싱보다는 서버에서 기억해둔 사용자 ID를 가져오는 듯한?
코드인 것 같은데 혹시 어떻게 되는 걸까요??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서는 바인딩할 객체를 리턴해주는 코드입니다!!
그래서 여기서 getPrincipal로 userId를 가져옵니다!

Comment on lines +8 to +11
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface MemberId {
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오... 어노테이션 활용하는 거 처음 봤습니다.
대박 🥹🥹

Comment on lines 42 to 44
} catch(UnauthorizedException e){
// throw new RuntimeException(String.valueOf(ErrorMessage.JWT_UNAUTHORIZED_EXCEPTION));
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기 예외 잡아서 어떻게 처리하게 되는 걸까요?!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 로그찍는거를 까먹었네요!

추가로 말씀드리자면!
여기서 throw를 던지면 ExceptionTranslationFilter 에 의해 동작하는 AuthenticationEntryPoint 을 상속한 CustomJwtAuthenticationEntryPoint 가 호출됩니다. 그러면 securityConfig에서 해준 permitAll이 먹지 않는데요! 이유는 아래 코드가 동작하지 않기 때문에, 필터가 넘어가지 않습니다. 그래서 여기서는 로그로만 찍었습니다.

filterChain.doFilter(request, response);

자세한 내용은 아래 블로그 참고해주시면 감사하겠습니다!!
permitAll이 안돼서 고생했었는데, 아래 블로그에서 자세히 설명해주어서 새로 배웠습니다~

https://velog.io/@choidongkuen/Spring-Security-SecurityConfig-%ED%81%B4%EB%9E%98%EC%8A%A4%EC%9D%98-permitAll-%EC%9D%B4-%EC%A0%81%EC%9A%A9%EB%90%98%EC%A7%80-%EC%95%8A%EC%95%98%EB%8D%98-%EC%9D%B4%EC%9C%A0

Comment on lines 76 to 78
} catch (EntityNotFoundException e) {
throw new NotFoundException(ErrorMessage.MEMBER_NOT_FOUND);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서 다시 예외를 던지시는 이유가 궁금합니다!!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 원하는 메세지를 던져주려고 저렇게 했습니다!
다른 방법이 있을까요??

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getRefreshTokenFromRedis를 보면 이미 안에 NotFoundException을 던지는 것으로 보이는데 혹시 EntityNotFoundException가 터질 일이 있나요?? 어느 상황에 터지는걸까요?(진짜 단순 궁금)

Copy link

@junggyo1020 junggyo1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코드 안의 주석이 잘 작성되어 있어서 코드를 이해하기 쉬웠어요...!!! 에러 처리를 한 경우에 어떤 상황에서 예외처리를 진행하는지에 대한 주석도 추가되면 더 좋을 것 같아요 ㅎㅎ 정말 고생하셨습니다.

Comment on lines 24 to 26
public String generateToken(final Long userId, boolean isAccessToken) {
final Date presentDate = new Date();
final Date expireDate = generateExpireDataByToken(isAccessToken, presentDate);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

메서드명에 오타가 있는 것 같아요!! 'generateExpireDataByToken' -> 'generateExpireDateByToken'이 맞는 것 같습니다. 오타를 수정하면 더 메서드명이 직관적이고 좋을 것 같아요!!!

Comment on lines 19 to 21
//타입이 같은지 확인
boolean isLongType = Long.class.isAssignableFrom(parameter.getParameterType());

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isAssignableFrom 메서드는 보통 서브클래스 관계를 확인하는데 사용되는데요!
'A.class.isAssinableFrom(B.class)'는 "B가 A의 서브클래스이거나 같은 클래스인지?"를 확인하는 반면 equals 메서드는 "두 클래스 객체가 정확히 같은지?" 확인하는 메서드입니다.
그래서 이 경우에 정확히 'Long' 클래스인지를 확인하는 것이기 때문에 parameter.getParameterType().equals(Long.class)를 사용하는 방법도 좋아보이네요..!!
참고 링크 남겨두겠습니다:)
docs.oracle.com/javase/8/docs/api/java/lang/Class.html#isAssignableFrom-java.lang.Class-

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오오오 감사합니다!

Copy link

@tkdwns414 tkdwns414 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요 성준님. 처음으로 리뷰해보는데 진짜 정말정말 열심히 하시네요. 깜짝 놀랐습니다. 부족하지만 궁금한 점들과 함께 리뷰 남기고 갑니다!

Comment on lines +17 to +41
public void validateAccessToken(String accessToken) {
try {
parseToken(accessToken);
} catch (ExpiredJwtException e) {
throw new UnauthorizedException(ErrorMessage.EXPIRED_ACCESS_TOKEN);
} catch (MalformedJwtException ex) {
throw new UnauthorizedException(ErrorMessage.INVALID_ACCESS_TOKEN);
} catch (UnsupportedJwtException ex) {
throw new UnauthorizedException(ErrorMessage.UNSUPPORTED_ACCESS_TOKEN);
} catch (IllegalArgumentException ex) {
throw new UnauthorizedException(ErrorMessage.EMPTY_ACCESS_TOKEN);
} catch (SignatureException ex) {
throw new UnauthorizedException(ErrorMessage.JWT_SIGNATURE_EXCEPTION);
}
}

public void validateRefreshToken(String refreshToken) {
try {
parseToken(refreshToken);
} catch (ExpiredJwtException e) {
throw new UnauthorizedException(ErrorMessage.EXPIRED_REFRESH_TOKEN);
} catch (Exception e) {
throw new UnauthorizedException(ErrorMessage.INVALID_REFRESH_TOKEN_VALUE);
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

atk와 rtk의 내용이 다르지 않다면 같은 validate를 쓰는 것도 나쁘지 않을 것 같은데 따로 분리한 이유가 있을까요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

에러 메세지가 다르기 때문에 나눠주었습니다!!

.body(
userJoinResponse
);
@PostMapping("signup")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앞에 /가 빠진 것 같아용~

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

헉 감사합니다!!

그리고 아래 getRefreshTokenFromRedis쪽에 남겨주신거에 제 커맨트를 남길수가 없어서 여기다가 대신 남깁니다!
저도 지금 다시 보니 현재 제 생각도 상준님말씀처럼 EntityNotFoundException가 터질일이 없을 것 같네요!
감사합니다!!

Comment on lines 76 to 78
} catch (EntityNotFoundException e) {
throw new NotFoundException(ErrorMessage.MEMBER_NOT_FOUND);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getRefreshTokenFromRedis를 보면 이미 안에 NotFoundException을 던지는 것으로 보이는데 혹시 EntityNotFoundException가 터질 일이 있나요?? 어느 상황에 터지는걸까요?(진짜 단순 궁금)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6차 세미나 실습 과제
5 participants